from aiohttp import web
from collections import defaultdict

import asyncio
import base64
import binascii
import copy
import types

from .client import ejson as json


async def authenticate(middleware, req):

    auth = req.headers.get('Authorization')
    if auth is None or not auth.startswith('Basic '):
        raise web.HTTPUnauthorized()
    try:
        username, password = base64.b64decode(auth[6:]).decode('utf8').split(':', 1)
    except binascii.Error:
        raise web.HTTPUnauthorized()

    try:
        if not await middleware.call('auth.check_user', username, password):
            raise web.HTTPUnauthorized()
    except web.HTTPUnauthorized:
        raise
    except Exception as e:
        raise web.HTTPUnauthorized()


class RESTfulAPI(object):

    def __init__(self, middleware, app):
        self.middleware = middleware
        self.app = app

        # Keep methods cached for future lookups
        self._methods = {}
        self._methods_by_service = defaultdict(dict)

        self._openapi = OpenAPIResource(self)

    def get_app(self):
        return self.app

    async def register_resources(self):
        for methodname, method in list((await self.middleware.call('core.get_methods')).items()):
            self._methods[methodname] = method
            self._methods_by_service[methodname.rsplit('.', 1)[0]][methodname] = method
        for name, service in list((await self.middleware.call('core.get_services')).items()):

            kwargs = {}
            blacklist_methods = []
            """
            Hook up methods for the resource entrypoint.
            For CRUD:
              - GET -> $name.query
              - POST - $name.create
            For Config:
              - GET -> $name.config
              - PUT -> $name.update
            """
            if service['type'] == 'crud':
                kwargs['get'] = '{}.query'.format(name)
                post = f'{name}.create'
                if post in self._methods:
                    kwargs['post'] = '{}.create'.format(name)
                blacklist_methods.extend(list(kwargs.values()))
            elif service['type'] == 'config':
                kwargs['get'] = '{}.config'.format(name)
                put = '{}.update'.format(name)
                if put in self._methods:
                    kwargs['put'] = put
                blacklist_methods.extend(list(kwargs.values()))

            service_resource = Resource(self, self.middleware, name.replace('.', '/'), **kwargs)

            """
            For CRUD services we also need a direct subresource so we can
            operate on items in the entity, e.g. update or delete "john" of user namespace.
            """
            subresource = None
            if service['type'] == 'crud':
                kwargs = {}
                delete = f'{name}.delete'
                if delete in self._methods:
                    kwargs['delete'] = delete
                put = f'{name}.update'
                if put in self._methods:
                    kwargs['put'] = put
                blacklist_methods.extend(list(kwargs.values()))
                subresource = Resource(self, self.middleware, 'id/{id}', parent=service_resource, **kwargs)

            for methodname, method in list(self._methods_by_service[name].items()):
                if methodname in blacklist_methods:
                    continue
                if method['require_websocket']:
                    continue
                short_methodname = methodname.rsplit('.', 1)[-1]
                if method.get('item_method') is True:
                    parent = subresource
                else:
                    parent = service_resource

                res_kwargs = {}
                """
                Methods with not empty accepts list and not filterable
                are treated as POST HTTP methods.
                """
                if method['accepts'] and not method['filterable']:
                    res_kwargs['post'] = methodname
                else:
                    res_kwargs['get'] = methodname
                Resource(self, self.middleware, short_methodname, parent=parent, **res_kwargs)
            await asyncio.sleep(0)  # Force context switch


class OpenAPIResource(object):

    def __init__(self, rest):
        self.rest = rest
        self.rest.app.router.add_route('GET', '/api/v2.0', self.get)
        self.rest.app.router.add_route('GET', '/api/v2.0/openapi.json', self.get)
        self._paths = defaultdict(dict)
        self._schemas = dict()
        self._components = defaultdict(dict)
        self._components['schemas'] = self._schemas
        self._components['responses'] = {
            'NotFound': {
                'description': 'Endpoint not found',
            },
            'Unauthorized': {
                'description': 'No authorization for this endpoint',
            },
            'Success': {
                'description': 'Operation succeeded',
            },
        }
        self._components['securitySchemes'] = {
            'basic': {
                'type': 'http',
                'scheme': 'basic'
            },
        }

    def add_path(self, path, operation, methodname, params=None):
        assert operation in ('get', 'post', 'put', 'delete')
        opobject = {
            'tags': [methodname.rsplit('.', 1)[0]],
            'responses': {
                '200': {'$ref': '#/components/responses/Success'},
                '401': {'$ref': '#/components/responses/Unauthorized'},
            },
            'parameters': [],
        }
        method = self.rest._methods.get(methodname)
        if method:
            desc = method.get('description')
            if desc:
                opobject['description'] = desc

            accepts = method.get('accepts')
            if method['filterable']:
                opobject['parameters'] += [
                    {
                        'name': 'limit',
                        'in': 'query',
                        'required': False,
                        'schema': {'type': 'integer'},
                    },
                    {
                        'name': 'offset',
                        'in': 'query',
                        'required': False,
                        'schema': {'type': 'integer'},
                    },
                    {
                        'name': 'count',
                        'in': 'query',
                        'required': False,
                        'schema': {'type': 'boolean'},
                    },
                    {
                        'name': 'sort',
                        'in': 'query',
                        'required': False,
                        'schema': {'type': 'string'},
                    },
                ]
            elif accepts:
                opobject['requestBody'] = self._accepts_to_request(methodname, method, accepts)

            # For now we only accept `id` as an url parameters
            if '{id}' in path:
                opobject['parameters'].append({
                    'name': 'id',
                    'in': 'path',
                    'required': True,
                    'schema': self._convert_schema(accepts[0]) if accepts else {'type': 'integer'},
                })

        self._paths[f'/{path}'][operation] = opobject

    def _convert_schema(self, schema):
        """
        Convert JSON Schema to OpenAPI Schema
        """
        schema = copy.deepcopy(schema)
        _type = schema.get('type')
        schema.pop('_required_', None)
        if isinstance(_type, list):
            if 'null' in _type:
                _type.remove('null')
                schema['nullable'] = True
            schema['type'] = _type = _type[0]
        if _type == 'object':
            for key, val in schema['properties'].items():
                schema['properties'][key] = self._convert_schema(val)
        return schema

    def _accepts_to_request(self, methodname, method, schemas):

        # Create an unique ID for every argument and register the schema
        ids = []
        for i, schema in enumerate(schemas):
            if i == 0 and method['item_method']:
                continue
            unique_id = f'{methodname.replace(".", "_")}_{i}'
            self._schemas[unique_id] = self._convert_schema(schema)
            ids.append(unique_id)

        if len(ids) == 1:
            schema = f'#/components/schemas/{ids[0]}'
        else:
            # If the method accepts multiple arguments lets emulate/create
            # a new schema, which is a object containing every argument as an
            # attribute.
            props = {}
            for i in ids:
                schema = self._schemas[i]
                props[schema['title']] = {'$ref': f'#/components/schemas/{i}'}
            new_schema = {
                'type': 'object',
                'properties': props
            }
            new_id = f'{methodname.replace(".", "_")}'
            self._schemas[new_id] = new_schema
            schema = f'#/components/schemas/{new_id}'
        return {
            'content': {
                'application/json': {
                    'schema': {'$ref': schema},
                },
            }
        }

    def get(self, req, **kwargs):

        servers = []
        host = req.headers.get('Host')
        if host:
            servers.append({
                'url': f'{req.scheme}://{host}/api/v2.0',
            })

        result = {
            'openapi': '3.0.0',
            'info': {
                'title': 'FreeNAS RESTful API',
                'version': 'v2.0',
            },
            'paths': self._paths,
            'servers': servers,
            'components': self._components,
            'security': [{'basic': []}],
        }

        resp = web.Response()
        resp.text = json.dumps(result, indent=True)
        return resp


class Resource(object):

    name = None
    parent = None

    delete = None
    get = None
    post = None
    put = None

    def __init__(
        self, rest, middleware, name, parent=None,
        delete=None, get=None, post=None, put=None,
    ):
        self.rest = rest
        self.middleware = middleware
        self.name = name
        self.parent = parent
        self.__method_params = {}

        path = self.get_path()
        if delete:
            self.delete = delete
        if get:
            self.get = get
        if post:
            self.post = post
        if put:
            self.put = put

        for i in ('delete', 'get', 'post', 'put'):
            operation = getattr(self, i)
            if operation is None:
                continue
            self.rest.app.router.add_route(i.upper(), '/api/v2.0/' + path, getattr(self, f'on_{i}'))
            self.rest._openapi.add_path(path, i, operation)
            self.__map_method_params(operation)

        self.middleware.logger.trace(f"add route {self.get_path()}")

    def __map_method_params(self, method_name):
        """
        Middleware methods which accepts more than one argument are mapped to a single
        schema of object type.
        For that reason we need to keep track of each parameter and its order
        """
        method = self.rest._methods.get(method_name)
        if not method:
            return
        accepts = method.get('accepts')
        self.__method_params[method_name] = {}
        if accepts is None:
            return
        for i, accept in enumerate(accepts):
            # First param of an `item_method` is the item `id` and must be skipped
            # since thats gotten from the URL.
            if i == 0 and method['item_method']:
                continue
            self.__method_params[method_name][accept['title']] = {
                'order': i,
                'required': accept['_required_'],
            }

    def __getattr__(self, attr):
        if attr in ('on_get', 'on_post', 'on_delete', 'on_put'):
            do = object.__getattribute__(self, 'do')
            method = attr.split('_')[-1]

            if object.__getattribute__(self, method) is None:
                return None

            async def on_method(req, *args, **kwargs):
                resp = web.Response()
                await authenticate(self.middleware, req)
                kwargs.update(dict(req.match_info))
                return await do(method, req, resp, *args, **kwargs)

            return on_method
        return object.__getattribute__(self, attr)

    def get_path(self):
        path = []
        parent = self.parent
        while parent is not None:
            path.append(parent.name)
            parent = parent.parent
        path.reverse()
        path.append(self.name)
        return '/'.join(path)

    def _filterable_args(self, req):
        filters = []
        options = {}
        for key, val in list(req.query.items()):
            if '__' in key:
                field, op = key.split('__', 1)
            else:
                field, op = key, '='

            def convert(val):
                if val.isdigit():
                    val = int(val)
                elif val.lower() in ('true', 'false', '0', '1'):
                    if val.lower() in ('true', '1'):
                        val = True
                    elif val.lower() in ('false', '0'):
                        val = False
                return val

            if key in ('limit', 'offset', 'count'):
                options[key] = convert(val)
                continue
            elif key == 'sort':
                options[key] = [convert(v) for v in val.split(',')]
                continue

            op_map = {
                'eq': '=',
                'neq': '!=',
                'gt': '>',
                'lt': '<',
                'gte': '>=',
                'lte': '<=',
                'regex': '~',
            }

            op = op_map.get(op, op)

            if val.isdigit():
                val = int(val)
            elif val.lower() == 'true':
                val = True
            elif val.lower() == 'false':
                val = False
            elif val.lower() == 'null':
                val = None
            filters.append((field, op, val))

        return [filters, options]

    async def do(self, http_method, req, resp, **kwargs):
        assert http_method in ('delete', 'get', 'post', 'put')

        methodname = getattr(self, http_method)
        method = self.rest._methods[methodname]
        """
        Arguments for a method can be grabbed from an override method in
        the form of "get_{get,post,put,delete}_args", e.g.:

          def get_post_args(self, req, resp, **kwargs):
              return [await req.json(), True, False]
        """
        get_method_args = getattr(self, 'get_{}_args'.format(http_method), None)
        if get_method_args is not None:
            method_args = get_method_args(req, resp, **kwargs)
        else:
            if http_method in ('post', 'put'):
                try:
                    text = await req.text()
                    if not text:
                        method_args = []
                    else:
                        data = await req.json()
                        params = self.__method_params.get(methodname)
                        if not params or len(params) == 1:
                            method_args = [data]
                        else:
                            if not isinstance(data, dict):
                                resp.set_status(400)
                                resp.body = json.dumps({
                                    'message': 'Endpoint accepts multiple params, object/dict expected.',
                                })
                                return resp
                            method_args = []
                            for p, options in sorted(params.items(), key=lambda x: x[1]['order']):
                                if p not in data and options['required']:
                                    resp.body = json.dumps({
                                        'message': f'{p} attribute expected.',
                                    })
                                    return resp
                                elif p in data:
                                    method_args.append(data[p])
                except Exception as e:
                    resp.set_status(400)
                    resp.body = json.dumps({
                        'message': str(e),
                    })
                    return resp
            elif http_method == 'get' and method['filterable']:
                method_args = self._filterable_args(req)
            else:
                method_args = []

        """
        If the method is marked `item_method` then the first argument
        must be the item id (from url param)
        """
        if method.get('item_method') is True:
            method_args.insert(0, kwargs['id'])

        result = await self.middleware.call(methodname, *method_args)
        if isinstance(result, types.GeneratorType):
            result = list(result)
        elif isinstance(result, types.AsyncGeneratorType):
            result = [i async for i in result]
        resp.text = json.dumps(result, indent=True)
        return resp
